Use Case
The primary use case for Query
involves:
- Asynchronous Data Fetching: Seamlessly fetching data from APIs or databases asynchronously without blocking the UI, improving the user experience.
- State Management for Remote Data: Managing the state of remote data, including loading states, error states, and the data itself, in a concise and scalable manner.
- Caching: Storing fetched data in a cache to improve performance by reducing the number of redundant requests to the server.
- Automatic Updates: Automatically updating the UI when the underlying data changes, without requiring explicit refresh actions from the user.
- Concurrent Requests Management: Efficiently handling multiple, concurrent data fetches through strategies like merging, switching, or concatenating requests.
- Retry and Error Handling: Automatically retrying failed requests and handling errors gracefully to ensure application stability.
Benefits
Using a Query
mechanism offers numerous benefits, including:
- Improved Performance and Efficiency: By caching responses and reducing unnecessary server requests, applications load faster and use fewer resources, both on the client and server side.
- Simplified Data Fetching Logic: It abstracts away the boilerplate code associated with fetching data, handling errors, and managing response states, leading to cleaner and more maintainable code.
- Automatic Synchronization:
Query
libraries often come with features to automatically refetch data on certain triggers (e.g., window focus), ensuring the UI is always up-to-date with the latest server state without manual intervention. - Built-in Asynchronous Management: Handling asynchronous data fetches becomes straightforward, with built-in support for loading states, error handling, and data updates.
- Scalability: Easily scalable for complex applications, supporting various fetching strategies to manage multiple data sources, endpoints, and concurrent requests effectively.
- Developer Experience: By standardizing the approach to data fetching and state management, it enhances developer experience, reducing the cognitive load and making it easier to onboard new developers.
- Robust Error and Retry Handling: Features to automatically retry requests and sophisticated mechanisms for error handling improve application reliability.
- Customizable and Extendable: While offering sensible defaults for most use cases,
Query
implementations are usually highly customizable, allowing developers to tailor their behavior for specific needs, such as custom caching strategies, query deduplication, and more.
Configuration
When setting up a Query
, you can typically configure it with several options to tailor its behavior to your application's specific needs. While the exact options available can vary depending on the implementation of the Query
, common configuration parameters often include:
-
fn
(Function):
- This is the core function that the client will use to fetch data. It receives the querying arguments and returns a promise which resolves with the fetched data. Ideally, this function encapsulates your API call, utilizing
fetch
, Axios, or any other HTTP client.
-
Retry Strategy:
- Options to configure how the client should behave in the event of a failed request. This could include the maximum number of retries, the conditions under which a retry should occur, and the delay between retries.
-
Caching Strategy:
- Defines how and what the client should cache. This could include:
expire
: Time in milliseconds after which a cached item is considered stale.- Custom cache keys: A function to generate unique cache keys based on query arguments.
- Cache validation: Function to determine if cached data is still valid based on custom logic.
-
Concurrent Request Handling (Queuing Strategy):
- Determines how the client manages simultaneous requests. Common strategies might include:
switch
: Cancels any ongoing request when a new request comes in.merge
: Allows multiple requests to run in parallel.concat
: Queues requests and executes them sequentially.
-
Logging / Debugging:
- Integrations or options for logging queries, responses, and errors for debugging purposes. This might include specifying a logger instance or configuring log levels.
-
Request Transformation:
- Functions to modify requests before they are sent. This could be used to add authorization headers, manipulate query parameters, or transform request bodies.
-
Response Transformation:
- Similarly, functions to process data before it's handed off to your application code. This could involve shaping the response data or extracting relevant parts of it.
-
Error Handling:
- Strategies to handle errors globally, such as transforming error responses or triggering global error states.
-
signal
(AbortSignal):
- Support for passing an
AbortSignal
to requests, allowing you to cancel them programmatically if needed.
-
Extended Configuration:
- Depending on the specific implementation, there may be additional options to tweak the client's internals, such as modifying timeout durations, setting base URLs for requests, or configuring default headers.
Example of a Basic QueryClient Setup
const queryClient = new QueryClient({
fn: async (args) => {
const response = await fetch(`https://your.api/${args.endpoint}`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(args.params),
});
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
},
expire: 60000,
retry: { attempts: 3, delay: 1000 },
});
Observable
const args = {
};
query.query(args).subscribe({
next: (result) => console.log(result),
error: (error) => console.error(error),
complete: () => console.log('Query completed'),
});
Asynchronous
const args = {
};
query
.queryAsync(args)
.then((result) => console.log(result))
.catch((error) => console.error(error));
Managing Cache
Mutation
const args = {
};
const changes = (prevState) => ({ ...prevState });
query.mutate(args, changes);
Direct Cache Update (Optimistic Update)
If you're confident about the new state of the data after the mutation, you can apply an optimistic update:
async function updatePost(postId, newData) {
const updatedPost = await apiUpdatePost(postId, newData);
return updatedPost;
}
fastQueryClient.mutate(['post', postId], async (oldData) => {
const updatedData = await updatePost(postId, newData);
return { ...oldData, ...updatedData, updated: Date.now() };
});
In this case, by providing the updated
attribute with the current timestamp, FastQuery knows that the cached data is fresh, preventing unnecessary refetches.
Marking Cache As Stale
If the final state of the data after the mutation is uncertain or if it's preferable for the application to fetch fresh data from the server, you can choose to invalidate the cache item:
fastQueryClient.mutate(['post', postId], oldData => {
updatePost(postId, newData);
return { ...oldData, updated: undefined };
});
Invalidation
Invalidate a specific cache entry or all:
query.invalidate(args);
query.invalidate();
Subscriptions and Cleanup
Remember to unsubscribe from observables or to complete the query to release resources:
const subscription = query.query(args).subscribe((result) => console.log(result));
subscription.unsubscribe();
query.complete();
Setup
When compiling code the FUSION_LOG_LEVEL
must be set (depending on build util, example shown for Vite)
Uncaught ReferenceError: process is not defined
defineConfig({
plugins: [
tsconfigPaths(),
viteEnv({
FUSION_LOG_LEVEL: process.env.FUSION_LOG_LEVEL ?? process.env.NODE_ENV === 'development' ? '3' : '1'
}),
]
})
## Advanced Usage
### Queue Operators
The Query utility allows you to manage concurrent requests using different queue strategies: `switch`, `merge`, and `concat`. Here's how you can apply each strategy:
#### Switch (Default)
Cancels the current active request when a new request comes in. Only the result from the latest request will be returned.
```typescript
import { Query } from '@equinor/fusion-query';
import { debounceTime, fromEvent, map, switchMap } from 'rxjs';
// Mock function to simulate data fetching based on the search query
async function fetchSearchResults(searchQuery: string) {
const response = await fetch(`https://your.api/search?query=${searchQuery}`);
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
}
// Define the query with the switch as a debounce strategy
const searchQuery = new Query<any, { searchQuery: string }>({
client: {
fn: ({ searchQuery }) => fetchSearchResults(searchQuery),
},
key: ({ searchQuery }) => `search-${searchQuery}`,
queueOperator: 'switch',
});
Set Up the Event Listener on the Search Input
Now, set up the search input to listen for changes and use the defined query to fetch data:
const searchInput = document.getElementById('search-input');
fromEvent(searchInput, 'input')
.pipe(
map((event) => (event.target as HTMLInputElement).value),
debounceTime(300),
switchMap((searchQuery) => (searchQuery ? searchQuery.query({ searchQuery }) : [])),
)
.subscribe({
next: (results) => {
console.log('Search Results:', results);
},
error: (error) => console.error('Error fetching search results:', error),
});
Merge
Allows multiple requests to run in parallel without canceling each other. All responses will be returned as they arrive.
async function fetchData(endpoint: string, queryParams: object) {
const response = await fetch(`https://your.api/${endpoint}`, {
method: 'GET',
body: JSON.stringify(queryParams),
});
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
}
import { Query } from '@equinor/fusion-query';
import { combineLatest } from 'rxjs';
const userProfileQuery = new Query<any, { endpoint: string; queryParams: object }>({
client: {
fn: fetchData,
},
key: (args) => `${args.endpoint}-${JSON.stringify(args.queryParams)}`,
queueOperator: 'merge',
});
const userId = 'exampleUserId';
const userDetails$ = userProfileQuery.query({ endpoint: 'users', queryParams: { userId } });
const userPosts$ = userProfileQuery.query({ endpoint: 'posts', queryParams: { userId } });
const userComments$ = userProfileQuery.query({ endpoint: 'comments', queryParams: { userId } });
combineLatest([userDetails$, userPosts$, userComments$]).subscribe({
next: ([userDetails, userPosts, userComments]) => {
console.log('User Details:', userDetails);
console.log('User Posts:', userPosts);
console.log('User Comments:', userComments);
},
error: (error) => console.error('Error fetching data:', error),
complete: () => console.log('All parallel queries completed'),
});
Concat
Queues requests and executes them one after another in a sequential manner. A new request will only start after the previous one has completed.
const query = new Query<YourDataType, YourArgsType>({
client,
key: (args) => JSON.stringify(args),
queueOperator: 'concat',
});
Cache Validation Strategies
Cache validation is crucial for determining whether cached data is still relevant or needs to be refreshed. You can customize cache validation using the validate
option.
Default Expiration Time
Automatically consider cache entries as stale after a specified duration.
const query = new Query<YourDataType, YourArgsType>({
client,
key: (args) => JSON.stringify(args),
expire: 60000,
});
Custom Validation Function
Implement a custom logic to validate cache entries based on your requirements.
const query = new Query<YourDataType, YourArgsType>({
client,
key: (args) => JSON.stringify(args),
validate: (entry, args) => {
return Date.now() - entry.updated < 30 * 60 * 1000;
},
});
Setting Up Shared QueryCache and Data Fetch Function
First, establish a shared QueryCache
instance and define a common function to fetch data. This shared cache will be utilized by different query instances across the application.
import { Query, QueryCache } from '@equinor/fusion-query';
const sharedQueryCache = new QueryCache();
async function fetchBlogPosts() {
const response = await fetch('https://example.com/api/blog-posts');
if (!response.ok) {
throw new Error('Failed to fetch blog posts');
}
return response.json();
}
Next, directly initialize separate Query
instances in different parts of your application (e.g., for homepage posts and sidebar posts). These instances will share the same QueryCache
but are created independently.
const homepagePostsQuery = new Query({
client: {
fn: fetchBlogPosts,
},
cache: sharedQueryCache,
key: () => 'allBlogPosts',
});
homepagePostsQuery.query().subscribe({
next: (posts) => {
console.log('Homepage posts:', posts);
},
error: (error) => console.error('Error fetching posts for homepage:', error),
});
const sidebarPostsQuery = new Query({
client: {
fn: fetchBlogPosts,
},
cache: sharedQueryCache,
key: () => 'allBlogPosts',
});
sidebarPostsQuery.query().subscribe({
next: (posts) => {
console.log('Sidebar posts:', posts);
},
error: (error) => console.error('Error fetching posts for sidebar:', error),
});
Since both homepagePostsQuery
and sidebarPostsQuery
instances use the shared QueryCache
, fetching operations benefit from cache-first strategies, reducing unnecessary network requests.
- If
"allBlogPosts"
data is fetched first by the homepage and then requested by the sidebar, the sidebar will immediately access the cached data without needing to fetch from the network again. - Any updates to the cache by one query instance (either from fetching new data or manually updating the cache entries) are immediately available to the others, given they share the same cache and cache keys.